목록으로 돌아가기
nestjs에서 aop 적용하기(feat. nestjs는 어떻게 데코레이터를 등록할까?)

nestjs에서 aop 적용하기(feat. nestjs는 어떻게 데코레이터를 등록할까?)

2025년 1월 3일
4개 태그
Nestjs
AOP
Decorator
kafka

최근 서비스에서는 Kafka 메시지 처리를 위해 정규식 기반으로 여러 토픽을 하나의 consumer에서 처리하는 구조가 사용되었습니다.

하지만 이 방식은 토픽에 대한 가시성이 떨어지고, 한 컨슈머에 문제가 생기면 전체에 영향을 줄 수 있다는 한계가 있습니다.

이에 대한 개선 방안으로, 각 토픽마다 개별 컨슈머 그룹을 구성하고,

커스텀 데코레이터(@Consume)를 통해 컨트롤러의 특정 메서드를 Kafka Consumer로 등록하는 방법을 도입해보았습니다.

이 글에서는 NestJS에서 데코레이터가 어떻게 등록되고, 어떻게 동작하는지 자세히 설명하고,

실제 Kafka Consumer 초기화 로직에 어떻게 응용할 수 있는지 살펴보겠습니다.


NestJS에서 데코레이터가 등록되는 과정

NestJS에서 데코레이터는 **마킹(Marking) → 조회(Discovery) → 등록(Registration)**의 3단계로 동작합니다.

1. 마킹 (Marking)

SetMetadata 함수

import { SetMetadata } from '@nestjs/common';
import type { ConsumeDecoratorOptions } from '#/interfaces/consume-decorator.interface';
import 'reflect-metadata';

export const CONSUME_METADATA_KEY = Symbol('KAFKA_CONSUME_METADATA');

export function Consume(options: ConsumeDecoratorOptions): MethodDecorator {
  return SetMetadata(CONSUME_METADATA_KEY, options);
}
  • NestJS는 @SetMetadata(key, value)라는 헬퍼 함수를 제공합니다. 이 함수는 내부적으로 Reflect.defineMetadata(key, value, target)를 호출하여 대상(클래스나 메서드)에 메타데이터를 저장합니다.

예를 들어, 아래와 같이 커스텀 데코레이터를 정의할 수 있습니다:

이 데코레이터가 적용된 메서드에는 CONSUME_METADATA_KEY라는 키로 options가 등록됩니다.

즉, 메서드가 "마킹"되어 후에 조회할 때 이 옵션들이 함께 나타납니다.

2. 조회 (Discovery)

NestJS는 애플리케이션이 부팅되는 동안 모든 provider(Controller, Service 등)를 순회합니다.

이때, DiscoveryService와 MetadataScanner를 사용하여 데코레이터가 등록된 대상(메서드, 클래스 등)을 찾아냅니다.

  • **DiscoveryService:**모든 컨트롤러나 provider를 순회하며, 데코레이터가 적용된 인스턴스를 찾습니다.
  • **MetadataScanner:**각 인스턴스의 프로토타입을 조사하여, 데코레이터가 적용된 메서드들을 수집합니다.
  • **Reflector:**NestJS의 Reflector를 사용하면, Reflect.getMetadata를 래핑하여 쉽게 메타데이터를 조회할 수 있습니다.

3. 등록 (Registration)

조회 단계에서 수집된 데코레이터 메타데이터를 기반으로 실제 로직에 등록합니다.

예를 들어, Kafka Consumer 초기화 시 다음과 같이 처리할 수 있습니다:

@Injectable()
export class ConsumerInitializer implements OnModuleInit {
  constructor(
    private readonly kafkaService: KafkaService,
    private readonly reflector: Reflector,
    private readonly metadataScanner: MetadataScanner,
    private readonly discoveryService: DiscoveryService,
  ) {}

  async onModuleInit() {
    const methodList: {
      instance: object;
      methodRef: (...args: unknown[]) => Promise<void>;
      metadata: { topic: string; groupId: string };
    }[] = [];

// 모든 컨트롤러를 순회
this.discoveryService.getControllers().forEach((wrapper) => {
      if (!wrapper.isDependencyTreeStatic() || !wrapper.instance) return;

// 인스턴스의 모든 메서드 이름을 스캔
this.metadataScanner
        .getAllMethodNames(Object.getPrototypeOf(wrapper.instance) as object)
        .forEach((methodName) => {
          const methodRef = (wrapper.instance as Record<string, unknown>)[methodName] as (...args: unknown[]) => Promise<void>;
// Reflector를 통해 데코레이터로 등록된 메타데이터를 조회
const metadata = this.reflector.get<{ topic: string; groupId: string }>(CONSUME_METADATA_KEY, methodRef);
          if (metadata) {
            methodList.push({
              instance: wrapper.instance,
              methodRef: methodRef,
              metadata,
            });
          }
        });
    });

// 수집된 메서드에 대해 Kafka Consumer 등록await Promise.all(
      methodList.map(({ instance, methodRef, metadata }) =>
        this.kafkaService.initConsumer({
          groupId: metadata.groupId,
          topic: metadata.topic,
          autoCommit: true,
          eachMessage: methodRef.bind(instance),
        }),
      ),
    );
  }
}

각 컨트롤러를 순회하면서, 데코레이터가 등록된 메서드를 찾아낸 후, 해당 메서드를 Kafka Consumer의 콜백 함수(eachMessage)로 등록합니다.


개선된 구조: 도메인별 컨트롤러와 @Consume 데코레이터

기존에는 하나의 서비스가 정규식으로 여러 토픽을 처리하면서, 리밸런싱 등의 문제로 전체에 영향을 주었으나,

새로운 구조에서는 도메인별로 Controller를 분리하고, 각 Controller에서 아래와 같이 데코레이터를 사용합니다.

@Controller()
export class AlwaysAttendanceController {
  constructor(
    private readonly alwaysAttendanceService: AlwaysAttendanceService,
  ) {}

  @Consume({
    topic: TRAIN_LOG_TOPIC.GOORM_EDU_TRAIN_ALWAYS_LOG,
    groupId: 'gem-server-train-always-attendance',
  })
  async consumeAlwaysAttendanceMessage(
    message: AlwaysAttendanceTrainLogDocument,
  ) {
    await this.alwaysAttendanceService.processAlwaysAttendanceMessage({ message });
  }
}

장점:

  • 독립적 오프셋 관리: 각 토픽은 별도의 Consumer Group에서 관리되어, 한 토픽의 문제로 다른 토픽이 영향을 받지 않습니다.
  • 맞춤형 소비 로직: 도메인마다 개별 로직을 적용할 수 있어 유연성이 증가합니다.
  • 리밸런싱 영향 최소화: 각 그룹이 독립적으로 리밸런싱되므로 전체 시스템에 미치는 영향을 줄일 수 있습니다.
  • 도메인 분리: 도메인별로 Controller가 분리되어, 모듈 간 의존성이 줄어들고 관리가 용이해집니다.

마무리

NestJS의 커스텀 데코레이터 등록 과정(마킹 → 조회 → 등록)을 이해하면,

복잡한 로직을 모듈화하고, 동적으로 Consumer를 등록하는 구조를 쉽게 구현할 수 있습니다.

특히, @Consume 데코레이터를 활용하여 Kafka Consumer를 도메인별로 분리함으로써,

각 토픽의 소비를 독립적으로 관리하고, 시스템의 확장성과 안정성을 높일 수 있었습니다.

토스의 https://toss.tech/article/nestjs-custom-decorator 아티클을 참고하였습니다.

댓글 (0)

댓글 수정 시 필요합니다. 최소 4자 이상 입력해주세요.

아직 댓글이 없습니다. 첫 번째 댓글을 작성해보세요!